--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
Commit bf4da5d770806e03cde3b0a11bc7361cc0a8b55c
Parents : 944094f
Author : Ivan <ivan@quad4.io>
Signature : Signature validation error
Date : 2026-05-06T00:51:15-05:00
Update vendored LXMFy
Changes
9 files changed, 556 insertions(+), 58 deletions(-)
Diff
diff --git a/vendor/README.txt b/vendor/README.txt
index 9cd9a3da..db42ad81 100644
--- a/vendor/README.txt
+++ b/vendor/README.txt
@@ -2,7 +2,7 @@ Vendored third-party trees shipped inside the reticulum-meshchatx distribution.
lxmfy/
Upstream: https://git.quad4.io/LXMFy/LXMFy
- Bundled revision: a44b08f005bb32bda80bbd9f3c7b5c2baf57f580
+ Bundled revision: 0a6ba8c9fd0f306be614d0edce44e4e805c025b0
Declared version (pyproject): see vendor/lxmfy/pyproject.toml
Update: clone default branch, replace vendor/lxmfy (omit .git), align vendor/README
commit above, run poetry lock / uv lock, regenerate THIRD_PARTY_NOTICES if needed.
diff --git a/vendor/lxmfy/CHANGELOG.md b/vendor/lxmfy/CHANGELOG.md
index a8f15554..0a0864b9 100644
--- a/vendor/lxmfy/CHANGELOG.md
+++ b/vendor/lxmfy/CHANGELOG.md
@@ -1,5 +1,17 @@
# Changelog
+## [1.6.3] - 2026-05-06
+
+### Features
+- **LXMF FIELD_COMMANDS / FIELD_RESULTS support**: Bots can now receive structured commands and requests sent via LXMF message fields (`FIELD_COMMANDS = 0x09`) and automatically reply with results (`FIELD_RESULTS = 0x0A`).
+ - `unpack_commands(fields)` helper parses `FIELD_COMMANDS` whether sent as a single dict or a list of dicts.
+ - `pack_result(result, request_id, status)` helper builds `FIELD_RESULTS` reply payloads.
+ - Incoming field commands are routed through the existing command registry, sharing the same permission checks, type-hinted argument parsing, threading, and middleware hooks as text commands.
+ - `ctx.reply("...")` automatically includes `FIELD_RESULTS` in the outgoing LXMF message when responding to a field command. If the incoming command contained a `request_id`, it is preserved in the result for request/response correlation.
+ - The `msg` context object passed to command callbacks now exposes `.fields` (raw LXMF fields dict) and `.request_id`.
+ - New `BotConfig` option: `lxmf_commands_enabled` (default `True`). Set to `False` to ignore field commands and fall back to text-only processing.
+ - New exports: `FIELD_COMMANDS`, `FIELD_RESULTS`, `pack_result`, `unpack_commands` from `lxmfy` package.
+
## [1.6.2] - 2026-04-15
### Features
diff --git a/vendor/lxmfy/docs/source/api-reference.rst b/vendor/lxmfy/docs/source/api-reference.rst
index 6e44d4f5..488833da 100644
--- a/vendor/lxmfy/docs/source/api-reference.rst
+++ b/vendor/lxmfy/docs/source/api-reference.rst
@@ -42,7 +42,8 @@ The main bot class that handles message routing, command processing, and bot lif
external_cogs_timeout=30,
nlp_enabled=False,
nlp_threshold=0.5,
- link_support_enabled=False
+ link_support_enabled=False,
+ lxmf_commands_enabled=True
)
Key Methods
@@ -65,6 +66,33 @@ Key Methods
- :code:`on_message()`: Decorator for handling all messages (called before command processing)
- :code:`validate()`: Run validation checks on the bot configuration
+Structured Commands via LXMF Fields
+-----------------------------------
+
+Bots can receive commands sent via LXMF ``FIELD_COMMANDS`` (``0x09``) and automatically reply with ``FIELD_RESULTS`` (``0x0A``). This enables structured request/response workflows alongside normal text commands.
+
+Incoming ``FIELD_COMMANDS`` are parsed and routed through the same command registry as text commands, sharing permission checks, type-hinted argument parsing, threading, and middleware.
+
+.. code-block:: python
+
+ from lxmfy import LXMFBot, FIELD_COMMANDS, FIELD_RESULTS, pack_result, unpack_commands
+
+ bot = LXMFBot(name="FieldBot")
+
+ @bot.command(name="status", description="Return bot status")
+ def status_cmd(ctx):
+ # ctx.fields contains the raw LXMF fields dict
+ # ctx.request_id is set automatically if the command included one
+ ctx.reply("Bot is online")
+
+ # Sending a structured command from another LXMF client:
+ # lxm.fields[FIELD_COMMANDS] = {"command": "status", "args": [], "request_id": "abc123"}
+ # router.handle_outbound(lxm)
+
+ # The bot reply automatically includes FIELD_RESULTS with the response and request_id.
+
+To disable field command processing, set ``lxmf_commands_enabled=False`` in :code:`BotConfig`.
+
Storage
-------
diff --git a/vendor/lxmfy/docs/source/creating-bots.rst b/vendor/lxmfy/docs/source/creating-bots.rst
index 2ed137e2..7ad139d8 100644
--- a/vendor/lxmfy/docs/source/creating-bots.rst
+++ b/vendor/lxmfy/docs/source/creating-bots.rst
@@ -148,6 +148,68 @@ Then, you can define and use the icon:
This :code:`self.bot_icon_field` can be pre-calculated and reused for all messages sent by the bot.
+Structured Commands via LXMF Fields
+-----------------------------------
+
+In addition to text-based commands, LXMFy supports commands sent through LXMF message fields using ``FIELD_COMMANDS`` (``0x09``). This is useful for structured request/response workflows between LXMF clients and bots.
+
+When a message contains ``FIELD_COMMANDS``, the bot extracts the command name and arguments, routes them through the same command registry as text commands, and automatically includes ``FIELD_RESULTS`` (``0x0A``) in the reply.
+
+**Receiving structured commands**
+
+.. code-block:: python
+
+ from lxmfy import LXMFBot
+
+ bot = LXMFBot(name="FieldBot")
+
+ @bot.command(name="add", description="Add two numbers")
+ def add_command(ctx):
+ if len(ctx.args) >= 2:
+ try:
+ result = float(ctx.args[0]) + float(ctx.args[1])
+ ctx.reply(str(result))
+ except ValueError:
+ ctx.reply("Invalid numbers")
+ else:
+ ctx.reply("Usage: add <a> <b>")
+
+The ``ctx`` object in field command callbacks includes:
+
+- :code:`ctx.fields` — the raw LXMF fields dict from the incoming message
+- :code:`ctx.request_id` — the ``request_id`` from the incoming ``FIELD_COMMANDS`` (if any)
+
+**Sending a structured command from an LXMF client**
+
+.. code-block:: python
+
+ import LXMF
+ from lxmfy import FIELD_COMMANDS
+
+ lxm = LXMF.LXMessage(
+ destination,
+ source,
+ b"", # content can be empty for field-only commands
+ desired_method=LXMF.LXMessage.DIRECT,
+ )
+ lxm.fields[FIELD_COMMANDS] = {
+ "command": "add",
+ "args": ["3", "5"],
+ "request_id": "req-42", # optional, for correlation
+ }
+ router.handle_outbound(lxm)
+
+**Disabling field commands**
+
+If you want the bot to ignore ``FIELD_COMMANDS`` and only process text commands, set:
+
+.. code-block:: python
+
+ bot = LXMFBot(
+ name="TextOnlyBot",
+ lxmf_commands_enabled=False,
+ )
+
Using Cogs (Extensions)
-----------------------
diff --git a/vendor/lxmfy/lxmfy/__init__.py b/vendor/lxmfy/lxmfy/__init__.py
index 56abb457..391cc5e0 100644
--- a/vendor/lxmfy/lxmfy/__init__.py
+++ b/vendor/lxmfy/lxmfy/__init__.py
@@ -17,6 +17,7 @@ from .config import BotConfig
from .core import LXMFBot, BOT_DISPLAY_NAME_FILE
from .events import Event, EventManager, EventPriority
from .help import HelpFormatter, HelpSystem
+from .lxmf_fields import FIELD_COMMANDS, FIELD_RESULTS, pack_result, unpack_commands
from .middleware import MiddlewareContext, MiddlewareManager, MiddlewareType
from .permissions import DefaultPerms, PermissionManager, Role
from .scheduler import ScheduledTask, TaskScheduler
@@ -32,6 +33,8 @@ __all__ = [
"Event",
"EventManager",
"EventPriority",
+ "FIELD_COMMANDS",
+ "FIELD_RESULTS",
"HelpFormatter",
"HelpSystem",
"IconAppearance",
@@ -53,6 +56,8 @@ __all__ = [
"load_cogs_from_directory",
"pack_attachment",
"pack_icon_appearance_field",
+ "pack_result",
+ "unpack_commands",
"validate_bot",
]
diff --git a/vendor/lxmfy/lxmfy/config.py b/vendor/lxmfy/lxmfy/config.py
index d378f44f..9442ffd2 100644
--- a/vendor/lxmfy/lxmfy/config.py
+++ b/vendor/lxmfy/lxmfy/config.py
@@ -95,6 +95,7 @@ class BotConfig:
nlp_threshold: float = 0.5
link_support_enabled: bool = False
opportunistic_sending: bool = True
+ lxmf_commands_enabled: bool = True
def __post_init__(self):
"""Post-initialization to ensure admins is a set."""
diff --git a/vendor/lxmfy/lxmfy/core.py b/vendor/lxmfy/lxmfy/core.py
index a36715b7..3a12ac06 100644
--- a/vendor/lxmfy/lxmfy/core.py
+++ b/vendor/lxmfy/lxmfy/core.py
@@ -26,6 +26,7 @@ from .commands import Command
from .config import BotConfig
from .events import Event, EventManager, EventPriority
from .help import HelpSystem
+from .lxmf_fields import FIELD_RESULTS, pack_result, unpack_commands
from .middleware import MiddlewareContext, MiddlewareManager, MiddlewareType
from .moderation import SpamProtection
from .nlp import IntentClassifier
@@ -481,14 +482,89 @@ class LXMFBot:
self._reset_delivery_attempts(sender)
+ def _execute_command(self, cmd_name: str, args: list, msg) -> bool:
+ """Execute a registered command by name.
+
+ Returns:
+ True if the command was found and executed (or raised an error),
+ False if no command with that name exists.
+
+ """
+ if cmd_name not in self.commands:
+ return False
+
+ cmd = self.commands[cmd_name]
+
+ if not self.permissions.has_permission(msg.sender, cmd.permissions):
+ self.send(msg.sender, "You don't have permission to use this command.")
+ return True
+
+ try:
+ sig = inspect.signature(cmd.callback)
+ params = list(sig.parameters.values())
+
+ converted_args = []
+ for i, arg_val in enumerate(args):
+ param_idx = i + 1
+ if param_idx < len(params):
+ param = params[param_idx]
+ annotation = param.annotation
+ if (
+ annotation != inspect.Parameter.empty
+ and hasattr(annotation, "__call__")
+ and not isinstance(annotation, str)
+ ):
+ try:
+ converted_args.append(annotation(arg_val))
+ except (ValueError, TypeError):
+ converted_args.append(arg_val)
+ else:
+ converted_args.append(arg_val)
+ else:
+ converted_args.append(arg_val)
+
+ msg.args = converted_args
+ msg.is_admin = msg.sender in self.admins
+
+ if cmd.threaded:
+ self.thread_pool.submit(cmd.callback, msg)
+ else:
+ cmd.callback(msg)
+
+ self.middleware.execute(MiddlewareType.POST_COMMAND, msg)
+ return True
+
+ except Exception as e:
+ self.logger.error(
+ "Error executing command %s: %s",
+ cmd_name,
+ str(e),
+ )
+ self.send(msg.sender, f"Error executing command: {e}")
+ return True
+
def _process_message(self, message, sender):
"""Process an incoming message."""
try:
- content = message.content.decode("utf-8")
+ content = message.content.decode("utf-8") if message.content else ""
receipt = RNS.hexrep(message.hash, delimit=False)
+ msg_fields = getattr(message, "fields", None) or {}
+
+ field_commands = unpack_commands(msg_fields)
+ request_id = None
+ if field_commands:
+ request_id = field_commands[0].get("request_id")
+ has_field_commands = bool(field_commands)
def reply(response, **kwargs):
"""Helper function to reply to a message."""
+ lxmf_fields = kwargs.pop("lxmf_fields", None) or {}
+ if has_field_commands or request_id is not None:
+ lxmf_fields[FIELD_RESULTS] = pack_result(
+ response, request_id, kwargs.pop("status", "ok")
+ )
+ if lxmf_fields:
+ kwargs["lxmf_fields"] = lxmf_fields
self.send(sender, response, **kwargs)
if self.config.first_message_enabled:
@@ -518,6 +594,8 @@ class LXMFBot:
"sender": sender,
"content": content,
"hash": receipt,
+ "fields": msg_fields,
+ "request_id": request_id,
}
msg = SimpleNamespace(**msg_ctx)
@@ -525,67 +603,30 @@ class LXMFBot:
if self.middleware.execute(MiddlewareType.PRE_COMMAND, ctx) is None:
return
+ # Process structured commands from LXMF fields
+ if getattr(self.config, "lxmf_commands_enabled", True) and field_commands:
+ for cmd_data in field_commands:
+ cmd_name = cmd_data.get("command") or cmd_data.get("cmd")
+ cmd_args = cmd_data.get("args", [])
+ if isinstance(cmd_args, str):
+ cmd_args = [cmd_args]
+ if not isinstance(cmd_args, list):
+ cmd_args = []
+ if self._execute_command(cmd_name, cmd_args, msg):
+ return
+ else:
+ reply(f"Unknown command: {cmd_name}", status="error")
+ return
+
if self.command_prefix is None or content.startswith(self.command_prefix):
command_name = (
content.split()[0][len(self.command_prefix) :]
if self.command_prefix
else content.split()[0]
)
- if command_name in self.commands:
- cmd = self.commands[command_name]
-
- if not self.permissions.has_permission(sender, cmd.permissions):
- self.send(
- sender,
- "You don't have permission to use this command.",
- )
- return
-
- try:
- args = content.split()[1:] if len(content.split()) > 1 else []
-
- sig = inspect.signature(cmd.callback)
- params = list(sig.parameters.values())
-
- converted_args = []
- for i, arg_val in enumerate(args):
- param_idx = i + 1
- if param_idx < len(params):
- param = params[param_idx]
- annotation = param.annotation
- if (
- annotation != inspect.Parameter.empty
- and hasattr(annotation, "__call__")
- and not isinstance(annotation, str)
- ):
- try:
- converted_args.append(annotation(arg_val))
- except (ValueError, TypeError):
- converted_args.append(arg_val)
- else:
- converted_args.append(arg_val)
- else:
- converted_args.append(arg_val)
-
- msg.args = converted_args
- msg.is_admin = sender in self.admins
-
- if cmd.threaded:
- self.thread_pool.submit(cmd.callback, msg)
- else:
- cmd.callback(msg)
-
- self.middleware.execute(MiddlewareType.POST_COMMAND, msg)
- return
-
- except Exception as e:
- self.logger.error(
- "Error executing command %s: %s",
- command_name,
- str(e),
- )
- self.send(sender, "Error executing command: %s", str(e))
- return
+ args = content.split()[1:] if len(content.split()) > 1 else []
+ if self._execute_command(command_name, args, msg):
+ return
# NLP Intent matching
if self.config.nlp_enabled:
diff --git a/vendor/lxmfy/lxmfy/lxmf_fields.py b/vendor/lxmfy/lxmfy/lxmf_fields.py
new file mode 100644
index 00000000..4aefd872
--- /dev/null
+++ b/vendor/lxmfy/lxmfy/lxmf_fields.py
@@ -0,0 +1,53 @@
+"""LXMF field constants and helpers for structured commands and results."""
+
+try:
+ from LXMF.LXMF import FIELD_COMMANDS, FIELD_RESULTS
+except ImportError:
+ FIELD_COMMANDS = 0x09
+ FIELD_RESULTS = 0x0A
+
+
+def unpack_commands(fields: dict | None) -> list[dict]:
+ """Extract command entries from LXMF message fields.
+
+ Supports ``FIELD_COMMANDS`` being a single dict or a list of dicts.
+ Each dict should contain at least a ``command`` or ``cmd`` key.
+
+ Args:
+ fields: The ``fields`` dict from an :class:`LXMF.LXMessage`.
+
+ Returns:
+ A list of command dicts.
+
+ """
+ if not fields:
+ return []
+
+ raw = fields.get(FIELD_COMMANDS)
+ if raw is None:
+ return []
+
+ if isinstance(raw, dict):
+ return [raw]
+ if isinstance(raw, list):
+ return [entry for entry in raw if isinstance(entry, dict)]
+
+ return []
+
+
+def pack_result(result, request_id: str | None = None, status: str = "ok") -> dict:
+ """Pack a result into a ``FIELD_RESULTS``-compatible dict.
+
+ Args:
+ result: The result payload (any serialisable value).
+ request_id: Optional correlation ID from the original request.
+ status: Status string, e.g. ``"ok"`` or ``"error"``.
+
+ Returns:
+ A dict suitable for ``FIELD_RESULTS``.
+
+ """
+ data = {"result": result, "status": status}
+ if request_id is not None:
+ data["request_id"] = request_id
+ return data
diff --git a/vendor/lxmfy/tests/test_lxmf_commands.py b/vendor/lxmfy/tests/test_lxmf_commands.py
new file mode 100644
index 00000000..44d6e9d3
--- /dev/null
+++ b/vendor/lxmfy/tests/test_lxmf_commands.py
@@ -0,0 +1,296 @@
+"""Tests for LXMF FIELD_COMMANDS and FIELD_RESULTS support."""
+
+from unittest.mock import Mock
+
+import pytest
+
+from lxmfy import BotConfig, LXMFBot
+from lxmfy.lxmf_fields import (
+ FIELD_COMMANDS,
+ FIELD_RESULTS,
+ pack_result,
+ unpack_commands,
+)
+
+
+class TestUnpackCommands:
+ """Test unpack_commands helper."""
+
+ def test_empty_fields(self):
+ assert unpack_commands(None) == []
+ assert unpack_commands({}) == []
+
+ def test_single_dict(self):
+ fields = {FIELD_COMMANDS: {"command": "ping", "args": []}}
+ assert unpack_commands(fields) == [{"command": "ping", "args": []}]
+
+ def test_list_of_dicts(self):
+ fields = {FIELD_COMMANDS: [{"command": "ping"}, {"command": "pong"}]}
+ result = unpack_commands(fields)
+ assert len(result) == 2
+ assert result[0]["command"] == "ping"
+ assert result[1]["command"] == "pong"
+
+ def test_ignores_non_dict_entries(self):
+ fields = {FIELD_COMMANDS: [{"command": "ping"}, "bad_entry", 123]}
+ result = unpack_commands(fields)
+ assert len(result) == 1
+
+ def test_no_command_key(self):
+ fields = {FIELD_COMMANDS: {"cmd": "ping", "args": []}}
+ assert unpack_commands(fields) == [{"cmd": "ping", "args": []}]
+
+
+class TestPackResult:
+ """Test pack_result helper."""
+
+ def test_basic_result(self):
+ result = pack_result("pong")
+ assert result["result"] == "pong"
+ assert result["status"] == "ok"
+ assert "request_id" not in result
+
+ def test_result_with_request_id(self):
+ result = pack_result("pong", request_id="abc123")
+ assert result["result"] == "pong"
+ assert result["request_id"] == "abc123"
+
+ def test_error_status(self):
+ result = pack_result("bad", status="error")
+ assert result["status"] == "error"
+
+
+class TestLXMFBotFieldCommands:
+ """Test LXMFBot handling of FIELD_COMMANDS."""
+
+ @pytest.fixture(scope="function")
+ def bot(self, tmp_path):
+ config = BotConfig(
+ name="TestBot",
+ test_mode=True,
+ first_message_enabled=False,
+ lxmf_commands_enabled=True,
+ command_prefix="/",
+ )
+ bot = LXMFBot(**config.__dict__)
+ bot.config_path = str(tmp_path)
+ yield bot
+ try:
+ bot.cleanup()
+ except Exception:
+ pass
+
+ def test_field_command_executes_registered_command(self, bot):
+ """A message with FIELD_COMMANDS should trigger the matching command."""
+ responses = []
+
+ @bot.command(name="ping")
+ def ping_cmd(ctx):
+ responses.append(ctx.content)
+ ctx.reply("pong")
+
+ mock_message = Mock()
+ mock_message.content = b""
+ mock_message.hash = b"msg_hash_1"
+ mock_message.fields = {FIELD_COMMANDS: {"command": "ping", "args": []}}
+
+ sent = []
+
+ def mock_send(dest, msg, title=None, **kwargs):
+ sent.append((dest, msg, kwargs.get("lxmf_fields")))
+
+ original_send = bot.send
+ bot.send = mock_send
+
+ bot._process_message(mock_message, "sender_hash")
+
+ bot.send = original_send
+
+ assert len(responses) == 1
+ assert len(sent) == 1
+ dest, msg, lxmf_fields = sent[0]
+ assert msg == "pong"
+ assert lxmf_fields is not None
+ assert FIELD_RESULTS in lxmf_fields
+
+ def test_field_command_with_request_id(self, bot):
+ """Replies to field commands should include FIELD_RESULTS with request_id."""
+
+ @bot.command(name="echo")
+ def echo_cmd(ctx):
+ ctx.reply(" ".join(ctx.args))
+
+ mock_message = Mock()
+ mock_message.content = b""
+ mock_message.hash = b"msg_hash_2"
+ mock_message.fields = {
+ FIELD_COMMANDS: {
+ "command": "echo",
+ "args": ["hello", "world"],
+ "request_id": "req-42",
+ }
+ }
+
+ sent = []
+
+ def mock_send(dest, msg, title=None, **kwargs):
+ sent.append((dest, msg, kwargs.get("lxmf_fields")))
+
+ original_send = bot.send
+ bot.send = mock_send
+
+ bot._process_message(mock_message, "sender_hash")
+
+ bot.send = original_send
+
+ assert len(sent) == 1
+ _dest, msg, lxmf_fields = sent[0]
+ assert msg == "hello world"
+ assert FIELD_RESULTS in lxmf_fields
+ assert lxmf_fields[FIELD_RESULTS]["request_id"] == "req-42"
+ assert lxmf_fields[FIELD_RESULTS]["status"] == "ok"
+
+ def test_field_command_unknown_command(self, bot):
+ """Unknown field commands should get an error reply with FIELD_RESULTS."""
+ mock_message = Mock()
+ mock_message.content = b""
+ mock_message.hash = b"msg_hash_3"
+ mock_message.fields = {FIELD_COMMANDS: {"command": "nope", "args": []}}
+
+ sent = []
+
+ def mock_send(dest, msg, title=None, **kwargs):
+ sent.append((dest, msg, kwargs.get("lxmf_fields")))
+
+ original_send = bot.send
+ bot.send = mock_send
+
+ bot._process_message(mock_message, "sender_hash")
+
+ bot.send = original_send
+
+ assert len(sent) == 1
+ _dest, msg, lxmf_fields = sent[0]
+ assert "Unknown command" in msg
+ assert FIELD_RESULTS in lxmf_fields
+ assert lxmf_fields[FIELD_RESULTS]["status"] == "error"
+
+ def test_field_command_disabled(self, bot):
+ """When lxmf_commands_enabled is False, field commands should be ignored."""
+ bot.config.lxmf_commands_enabled = False
+
+ text_responses = []
+
+ @bot.command(name="textonly")
+ def text_cmd(ctx):
+ text_responses.append(ctx.content)
+
+ # Also set up a normal text message handler path
+ mock_message = Mock()
+ mock_message.content = b"/textonly hello"
+ mock_message.hash = b"msg_hash_4"
+ mock_message.fields = {FIELD_COMMANDS: {"command": "textonly", "args": []}}
+
+ sent = []
+
+ def mock_send(dest, msg, title=None, **kwargs):
+ sent.append((dest, msg, kwargs.get("lxmf_fields")))
+
+ original_send = bot.send
+ bot.send = mock_send
+
+ bot._process_message(mock_message, "sender_hash")
+
+ bot.send = original_send
+
+ # Should have processed the text command, not the field command
+ assert len(text_responses) == 1
+ assert text_responses[0] == "/textonly hello"
+
+ def test_field_command_with_string_args(self, bot):
+ """FIELD_COMMANDS args sent as a single string should be wrapped in a list."""
+
+ @bot.command(name="greet")
+ def greet_cmd(ctx):
+ ctx.reply(f"Hi {ctx.args[0]}")
+
+ mock_message = Mock()
+ mock_message.content = b""
+ mock_message.hash = b"msg_hash_5"
+ mock_message.fields = {FIELD_COMMANDS: {"command": "greet", "args": "Alice"}}
+
+ sent = []
+
+ def mock_send(dest, msg, title=None, **kwargs):
+ sent.append((dest, msg, kwargs.get("lxmf_fields")))
+
+ original_send = bot.send
+ bot.send = mock_send
+
+ bot._process_message(mock_message, "sender_hash")
+
+ bot.send = original_send
+
+ assert len(sent) == 1
+ _dest, msg, _fields = sent[0]
+ assert msg == "Hi Alice"
+
+ def test_msg_context_has_fields_and_request_id(self, bot):
+ """The msg object passed to commands should expose fields and request_id."""
+ captured = {}
+
+ @bot.command(name="capture")
+ def capture_cmd(ctx):
+ captured["fields"] = ctx.fields
+ captured["request_id"] = ctx.request_id
+ ctx.reply("ok")
+
+ mock_message = Mock()
+ mock_message.content = b""
+ mock_message.hash = b"msg_hash_6"
+ mock_message.fields = {
+ FIELD_COMMANDS: {
+ "command": "capture",
+ "args": [],
+ "request_id": "req-99",
+ }
+ }
+
+ original_send = bot.send
+ bot.send = lambda *a, **k: None
+
+ bot._process_message(mock_message, "sender_hash")
+
+ bot.send = original_send
+
+ assert captured["fields"] == mock_message.fields
+ assert captured["request_id"] == "req-99"
+
+ def test_text_command_does_not_add_field_results(self, bot):
+ """Normal text commands without FIELD_COMMANDS should not inject FIELD_RESULTS."""
+
+ @bot.command(name="plain")
+ def plain_cmd(ctx):
+ ctx.reply("plain reply")
+
+ mock_message = Mock()
+ mock_message.content = b"/plain"
+ mock_message.hash = b"msg_hash_7"
+ mock_message.fields = {}
+
+ sent = []
+
+ def mock_send(dest, msg, title=None, **kwargs):
+ sent.append((dest, msg, kwargs.get("lxmf_fields")))
+
+ original_send = bot.send
+ bot.send = mock_send
+
+ bot._process_message(mock_message, "sender_hash")
+
+ bot.send = original_send
+
+ assert len(sent) == 1
+ _dest, _msg, lxmf_fields = sent[0]
+ # lxmf_fields should be None because there's no request_id
+ assert lxmf_fields is None
──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────